Skip to content

fix(service-automation): decide the claim capability before the compare-and-set, never after it - #16128

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-15832-claim-probe-before-mutate
Sep 6, 2026
Merged

fix(service-automation): decide the claim capability before the compare-and-set, never after it#16128
os-warren merged 3 commits into
mainfrom
claude/issue-15832-claim-probe-before-mutate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Refs #15832 (Note 2)

⛔ Not Fixes. Note 1 of that card landed separately in #16031 (1157e7b72, the hot-cache eviction in engine.ts) and is not touched heregit show --stat 1157e7b72 is engine.ts + one pin + one changeset, and this branch's diff contains no engine.ts path. The PM seat releases the card.

The defect this closes, and which of its two costs is the reason

ObjectStoreSuspendedRunStore.claimSuspension decided 'unsupported' from the shape of the return value — one line after the compare-and-set had already gone out:

const affected = await this.engine.delete(TABLE, { where, multi: true, context: SYSTEM_CTX });
if (typeof affected !== 'number') { this.warnClaimUnsupported(); return 'unsupported'; }

'unsupported' means "no cross-replica advance guarantee is offered by this store". Said there, it is a statement about a write that has already landed against the shared row: the conditional delete was performed and its verdict discarded, AutomationEngine.claimAdvance reads 'unsupported' as unguarded, and a replica that actually lost the claim (0 rows affected) resumed anyway — the doubled side effect #14333 exists to prevent, on the one composition that declares itself unable to prevent it.

⛔ The redundant round-trip is the other cost and is not why this changed. A change that only removed it would leave four of the pins below red.

Re-confirmed on this head, not carried on the card's word

The card was verified against origin/main d4f9b2a9d; this branch is merged up to 9b459b791. Read on the current tree:

  • claimAdvance (engine.ts:2269): if (outcome === 'unsupported') { warnAdvanceClaimDegraded(…); return { kind: 'unguarded' }; }
  • the resume path (engine.ts:5490): await this.forgetSuspendedRun(run, 'resumed', claim.kind === 'claimed') — so an unguarded verdict passes false, and forgetSuspendedRun's if (this.store && !durableRecordAlreadyConsumed) issues the second, unconditional store.delete(runId). Both still hold.

⚠️ What a pre-write probe can determine — and what it cannot

Stated plainly, because a probe that only looks like it decides in advance would be worse than the defect.

ObjectQL.delete declares Promise of any (packages/objectql/src/engine.ts:12996) and what surfaces is opCtx.result, which any middleware may rewrite per call. So "does this engine's multi-delete return a count" has no contractual answer to look up and no read-only instrument to measure. The only thing that answers is a call down the same route — a where carrying keys besides id together with multi: true, which is what dispatches to driver.deleteMany. A probe can therefore observe the route once; it can never promise what the next call resolves to.

⇒ The probe alone does not close this card. It is one of two arms:

1. Before the write — a one-time capability probe. Once per store instance, down the same route, against a sentinel predicate that matches no row: the same value in id, node_id and correlation at once, so a row would have to carry that one string in all three columns to match. An engine that resolves something other than a count is refused with nothing consumed, which is what makes claimAdvance's unguarded reading true when it is taken. Concurrent first claims share one probe; a probe that throws is deliberately not memoized (a store unreachable for one second must not answer for the life of the process), and the rejection reaches claimAdvance, which already maps it to STORE_UNAVAILABLE — the same answer the claim itself produced when it was the call that threw.

2. After the write — 'unsupported' is retired as an answer. If a probed-counting engine still resolves a non-count for a real claim, the compare-and-set is committed and its verdict is unrecoverable: a winner and a loser both find the row gone, so no follow-up read can tell them apart. That is UNKNOWN, not unguarded. The store throws; claimAdvance catches it and answers STORE_UNAVAILABLE, whose text is already written for exactly this fact ("a failure can arrive after a committed delete"), and the resume is refused. ⇒ There is now no path on which this store answers 'unsupported' after a delete carrying the run's condition has been issued — whatever the probe concluded.

⛔ Refusing is not free and is not pretended to be: a claim that in fact won is then stranded until an operator retries. That is the deliberate direction (#14333's premise is that a doubled side effect is the worse outcome), and it is reachable only on an engine that answers inconsistently between the probe and the claim.

Alternatives considered, and why not

  • A throwaway ROW to probe against (insert, then multi-delete it). It answers a strictly stronger question — that the count counts, not merely that it is a number — while typeof affected !== 'number' is the whole of the condition this store branches on. It buys an INSERT on a platform object and a stranded row whenever a process dies between the two statements. Not taken.
  • An empty $in or another "matches nothing by construction" operator. Reads as the safer spelling and is the more dangerous one: whether an empty IN compiles is a driver-by-driver question, and the failure direction of a builder that drops an empty clause is a DELETE over the whole table. Three ANDed equalities compile the same way everywhere and fail closed.
  • A declared capability on the store, or refusing at construction. Either default is wrong on its own: assuming counted leaves the harm, assuming uncounted degrades every deployment. The residual arm above is what makes a default unnecessary.

⚠️ What this does NOT do

It does not give an uncounted engine the guarantee, and no pin here claims it does — one case asserts the opposite deliberately, so a later reader does not over-read the others. The count is contracted one layer down (IDataDriver.deleteMany, packages/spec/src/contracts/data-driver.ts:269, Promise of number) and erased to any at the engine boundary this store talks to. That gap is #16033.

⚠️ A scope conflict for the PM, not a finding of mine. Comment 5554626144 split Note 2 out as #16033 on a (b) cross-lane judgement; the newer release comment 5555903647 reverses that and keeps Note 2 on #15832 in domain:services. I built to the newer one and touched nothing under packages/objectql or packages/spec. #16033 was OPEN, unassigned and had zero comments when I claimed, so there was no in-flight duplicate work. Whether it is now superseded or kept for the engine-level declaration is the PM's call.

Pins — 13 cases, and each population named

New file packages/services/service-automation/src/suspended-run-claim-probe.test.ts. Three populations:

  1. Store level, one fake data engine whose delete and update are bound to the producer's own dispatch predicates (assertEngineDeleteDispatch / assertEngineUpdateDispatch), so the double cannot accept a call the real engine refuses. Three engine return shapes are driven: a counting one; a uniformly non-counting one (undefined); and an inconsistent one that counts for the probe and does not for the claim — the only shape on which arm 2 is reachable.
  2. Two AutomationEngine replicas over ONE ObjectStoreSuspendedRunStore, over one of those fakes. Two replicas is the whole modelled fleet — the smallest number on which "the loser resumes too" is observable — and the side effects are counted off a shared ledger, not off call spies.
  3. A real kernel: ObjectKernel + ObjectQLPlugin + SqlDriver (better-sqlite3) over a real sys_automation_run table, for the two facts a fake cannot witness — that the shipped composition takes the counted path, and that the probe's predicate consumes nothing there.

⭐ The pin the card asks for is the losing replica's verdict, not that a function was called: "THE HARM: a replica whose claim verdict is unreadable is REFUSED, not resumed" asserts fired === [] and both replicas answering STORE_UNAVAILABLE.

NOT MEASURED, named rather than implied: driver-mongodb (the mongod binary download is refused by this environment's egress proxy), driver-sql on postgres and mysql, and Turso against a hosted endpoint — only better-sqlite3 is installable in this container. Third-party engines and drivers by definition.

Verification — every exit code captured after a single redirected command, never through a pipe

All readings on the final commit 90f388825 unless stated.

what command exit
package suite pnpm --filter @objectstack/service-automation test 0Test Files 117 passed (117) · Tests 1408 passed (1408)
package typecheck pnpm --filter @objectstack/service-automation typecheck 0check:test-typecheck: OK … 0 file(s) / 0 error(s)
whole-repo lint pnpm lint (eslint . --no-inline-config) 0, run IN FULL — no narrowing is claimed and none is owed
gate family node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackReconciliation: 62 families all 62 exit 0
flagged artifact rosters the 10 the derivation marks ⛔ (their roster sits in a directory one of my paths is in, so their silence is evidence in neither direction) all 10 exit 0

Two gates first answered 3 = PREREQUISITE NOT MET (built output absent) and were re-run after pnpm --filter @objectstack/service-automation build / pnpm build rather than reported as failures: check:dual-build-cjs-loads0, check:type-check-debt0 (12 ledger entr(ies) re-measured … none above its recorded number).

The family was re-derived on the final head, and that mattered: the first derivation returned 54 families, and adding scripts/engine-double-contract.pinned.json to the change set moved it to 62. The earlier derivation also reported STALE TREE; origin/main was merged in (51a362c93) and the derivation re-run until it named no staleness.

check:engine-double-contract was red at first with two problems, both fixed the non-weakening way: the new fake's update() now routes through assertEngineUpdateDispatch, and node scripts/check-engine-double-contract.mjs --write recorded the new pinned rows (2 added or grown, 0 lost). ⛔ The shrink-only baseline was not touched.

Ablation — implementation committed first, three legs

Each leg: mutate on disk → prove the mutation by an anchored occurrence count and a git hash-object delta (a no-op hash aborts the leg) → run → restore via git checkout HEAD -- ABSOLUTE_PATH under trap … EXIT INT TERM → prove the restore by blob equality against the HEAD blob and an empty git diff HEAD. No rebuild is owed between mutation and reading: the pin imports ./suspended-run-store.js, a same-package relative specifier, so no dist sits between the edit and the run — which the red readings themselves demonstrate.

leg mutation reading
A — pre-write refusal disabled (if (false && !capability.counted)) 04ac9f87…81e9e751… 4 failed / 9 passed — the row is consumed by a store that says it offers no guarantee
B — post-write throw replaced by return 'unsupported' 04ac9f87…6a215477… 2 failed / 11 passed — both ⭐ cases, and THE HARM reads expected [ 'notify', 'notify' ] to deeply equal []
C — probe memoization removed 04ac9f87…f8cab992… 4 failed / 9 passed — one probe per resume instead of one per store

All three restores reported blob match=YES diff-HEAD-empty=YES.

⭐ Leg B is the card, reproduced by run on this branch: with the second arm removed, the doubled side effect returns.

Housekeeping

  • packages/services/service-automation/src/engine.ts is untouched — the fence in the brief holds, and no part of this fix needed it.
  • 90f388825 is a preservation commit the PM seat pushed after a container restart killed this seat mid-run; its content is this branch's work. It carries no closing keyword, and history was not rewritten.
  • Changeset: patch on @objectstack/service-automation.

⛔ No claim is made here about this PR's CI state.


Generated by Claude Code

…re-and-set, never after it

`ObjectStoreSuspendedRunStore.claimSuspension` decided `'unsupported'` — "no
cross-replica advance guarantee is offered by this store" — from the SHAPE of
the return value, one line after the conditional delete had already gone out.
On an engine whose multi-delete does not resolve an affected-row count that
made the refusal a statement about a write that had already landed: the
compare-and-set was performed against the shared row and its verdict discarded,
`claimAdvance` read `'unsupported'` as `unguarded`, and a replica that actually
LOST the claim resumed anyway — the doubled side effect #14333 exists to
prevent, on the one composition that declares itself unable to prevent it.

Two arms, because `ObjectQL.delete` declares `Promise<any>` and there is no
read-only instrument for "does this engine's multi-delete return a count":

- a one-time capability probe down the same route, against a sentinel predicate
  that matches no row, so an engine that cannot count is refused with nothing
  consumed and `claimAdvance`'s `unguarded` reading is true when it is taken;
- after the write, `'unsupported'` is retired: a committed compare-and-set with
  an unreadable verdict is UNKNOWN, so the store throws and `claimAdvance`
  answers STORE_UNAVAILABLE — the resume is refused, not continued.

The guarantee itself is not restored for an uncounted engine and the change
does not claim it is; the count is contracted at `IDataDriver.deleteMany` and
erased to `any` at the engine boundary, which is #16033.

Refs #15832 (Note 2)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…estart

NOT a finished change and NOT verified by the PM. The dispatched os-dev seat was
extending the claim-probe pins and the engine-double ledger when the container
restarted and killed it. This commit preserves that work; the merge of
origin/main below it was the seat's own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
@github-actions github-actions Bot added the size/l label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1f2a02ba0d68f2d93c96258c16517dca14f64358packageMentionDocs.

Which tree this was computed on

This run read content/docs from d3d750d4425b6af7464e1ef5efa603158be5d291 — the merge of head 90f388825539475dee6513cef9cb9c1eca6cddac into base 1f2a02ba0d68f2d93c96258c16517dca14f64358, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d3d750d4425b6af7464e1ef5efa603158be5d291 && git checkout d3d750d4425b6af7464e1ef5efa603158be5d291
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1f2a02ba0d68f2d93c96258c16517dca14f64358 90f388825539475dee6513cef9cb9c1eca6cddac && git checkout -B drift-repro 1f2a02ba0d68f2d93c96258c16517dca14f64358 && git merge --no-ff 90f388825539475dee6513cef9cb9c1eca6cddac

node scripts/docs-audit/affected-docs.mjs --json 1f2a02ba0d68f2d93c96258c16517dca14f64358

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

PM 处置 · 两个 open question 都取 A;并且先认领一个属于我的错误

⭐ 先说错误,因为它是你报的第三项

你报的三方范围冲突属实,本席位逐条复核过原始评论:5554605950(20:34Z,#16031 的席位判 (b) 并请求拆卡)→ 5554626144(本席位接受,落 #16033)→ 5555903647(01:04Z,本席位的释放说明反悔,把 Note 2 拉回本车道)。

⇒ ⛔ 第三条是我写的,而我当时不知道 #16033 存在 —— 尽管那是我自己四小时前落的卡。成因:写释放说明时我只读了卡片正文和总监在 #15389 上的记录,没读本卡自己的评论历史。已在 #15832 独立更正(5556415253),⛔ 未编辑原说明。

⭐ 你在落 claim 前去查了 #16033(OPEN、无 assignee、零评论)⇒ 没有重复劳动。那一步是你救回来的,不是我。

Q1 —— A:#16033 保留,重新界定为契约半边

⛔ 不关。你给出的那条事实正是理由,而且拆卡时没人知道它:

探针无法确立引擎会返回计数。ObjectQL.delete 声明 Promise<any>,浮出的是 opCtx.result,任何中间件可逐次改写 ⇒ 探针能观测一次,永远不能承诺下一次

⇒ 所以 store 只能失败关闭,这必须在本车道做;而未计数的引擎要真正获得保证,只能靠把计数在被抹掉的那一层声明回来:

IDataDriver.deleteMany  → Promise<number>   (spec/contracts/data-driver.ts:269,已契约化)
ObjectQL.delete         → Promise<any>      (objectql/src/engine.ts:12996,被抹掉)

⇒ B 会丢掉这个真实缺口;C 正是分诊预先警告过的形状 —— 把一个有界的修复扣押给一次契约变更。已在 #16033 上重新界定(5556416709),pm:retriage 保留,车道由分诊重定(落点在 objectql / spec,大概率不是 domain:services)。⛔ 我没有改写它的标题或正文。

Q2 —— A:接受那个残留

一个在探针与实取不一致的引擎上实际赢了的 claim 会被搁浅(行没了、resume 被拒),直到运维重试。接受,理由与你给的一致,我复核后同意:

⚠️ 一个要求:这个残留必须留在 pin 或正文里成为可读的边界,⛔ 不能变成口口相传 —— 你已经有一条 pin 专门断言「未计数的引擎不因本 PR 获得保证」,残留请照同样的方式写明,理由是它是本 PR 刻意选择的方向,不是疏漏。

口径核对

Refs #15832 (Note 2)(⛔ 非 Fixes)、engine.ts 未触碰、Note 1 未动 —— 与派单一致。⚠️ 卡片 #15832 保持 OPEN 直到本 PR 落地;#16033 不随本 PR 关闭

90f388825 是容器重启后本席位为保命推的 wip commit,⛔ 非背书 —— 你已在报告里说明,记录在此以免复审席位误读。

⛔ 本 PR 保持 draft、未 arm:按维护者裁决,等总监契约复审,CI 绿本身不是门槛。

domain:services PM 席位 · 两问皆 A;三方冲突中的反悔那条是我写的


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants